Skip to content

repl: import the JSX runtime for JSX input - #46105

Open
robobun wants to merge 2 commits into
robobun/12d3c09e/repl-persist-declared-namesfrom
robobun/f54243c9/repl-jsx-runtime-import
Open

robobun wants to merge 2 commits into
robobun/12d3c09e/repl-persist-declared-namesfrom
robobun/f54243c9/repl-jsx-runtime-import

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #45915. The source also applies to main (Notes).

Problem

  • JSX in bun repl throws ReferenceError: jsxDEV_7x81h0kn is not defined under the default (automatic) JSX runtime, at every entry.
  • Repl::transform_for_repl (src/runtime/cli/repl.rs:2144) hand-builds its parser options and leaves features.auto_import_jsx false. The parser then prints the call without its import (src/js_parser/parse/parse_entry.rs:2278).

Fix

  • Forward vm.transpiler.options.auto_import_jsx, as Transpiler::parse does for a file. The REPL transform already turns the import into await import().
  • Copy the JSX import paths only where the parser generates an import.
  • Verified: test/js/bun/repl/repl.test.ts (20 new tests, 17 fail on the base), repl-transform.test.ts, transpiler.test.js, bundler_jsx.test.ts.
  • Self-reviewed: 29 concerns raised, 27 addressed. Not taken (Notes): forwarding five more options, splitting out the parser commit.

Background

Downsides

  • Changed: if the runtime does not resolve, an input that only declares JSX now fails whole, as a file does. 1.4.3 ran it.
  • With no node_modules, a JSX input asks the registry for react, as a file does. A silent registry blocks the prompt for 36 s, and Ctrl+C cannot stop it.
  • Cost: one import() per JSX input (7 ioctl, 2 rt_sigaction at a terminal). Without JSX: 0 allocations, 55 instructions more. Still failing: <p>it's</p>, export.
Notes

Commands that change

Rows 1 to 4: no react can be resolved (a node_modules directory without it, or --no-install).

Command 1.4.3 Now
bun repl -e 'console.log("ran", 42); function App() { return <div /> }' ran 42, exit 0 error: Cannot find module 'react/jsx-dev-runtime' from '.', nothing else, exit 1
bun repl -e 'console.log("ran"); if (false) { <div /> }' ran, exit 0 the same error, exit 1
bun repl -e 'try { <div /> } catch (e) { console.log("caught", e.name) }' caught ReferenceError, exit 0 the same error, exit 1
Prompt: globalThis.ready = 1; function App() { return <div /> }, then [globalThis.ready, typeof App] [ 1, "function" ] the error, then [ undefined, "undefined" ]
No node_modules at all: bun repl -e 'console.log((<div />).type)' ReferenceError, 0 registry requests 1 registry request for react, then the element or the not-found error
react installed: bun repl -e 'globalThis.jsxDEV_7x81h0kn = (type) => ({ via: "my global " + type }); console.log((<div />).via)' my global div the element of the installed runtime
  • "Now" in rows 1 to 4 is what bun -e prints on 1.4.3 for the same code. The import of the runtime is the first statement of the input, so when it fails no statement runs.
  • On 1.4.3 no input could evaluate a JSX element under the automatic runtime, so no working use of an element changes.

Origin and reach

  • No user reported this. It was found by running the REPL against its docs page (docs/runtime/repl.mdx:28, "TypeScript & JSX"). The tracker has no report of jsxDEV_7x81h0kn or of JSX for bun repl.
  • It never worked. The native REPL (feat(repl): implement native Zig REPL with full TUI support #26304, February 2026) parses every input as TSX with the VM's JSX options and never set the flag.
  • Intent, from the code and not from the docs page: the classic runtime already works at the prompt on 1.4.3, bun -e of the same code works, and the failure is a leaked internal name, not a diagnostic.
  • Who meets it: anyone who types JSX at the prompt with the default runtime, on every such input. There is no urgency, and it does not need to land ahead of other REPL work.

Repro (a stand-in react, so no third-party code runs)

mkdir -p repro/node_modules/react && cd repro
echo '{"name":"react","version":"0.0.0"}' > node_modules/react/package.json
echo 'exports.Fragment = "Fragment"; exports.jsxDEV = (type, props) => ({ type, props });' > node_modules/react/jsx-dev-runtime.js
printf 'var el = <div id="a">x</div>\nel.type\n.exit\n' | bun repl
bun repl -p '(<div />).type'
  • 1.4.3: ReferenceError: jsxDEV_7x81h0kn is not defined for both, and -p exits with 1. With a tsconfig "jsx": "react-jsx" the name is jsx_w77yafs4.
  • This branch: el.type and -p print "div".
  • bun -p with the same code prints div on every build.

What the tests cover

  • Entries: the prompt, input that continues over lines inside parentheses, .editor ended with Ctrl+D, .load, .copy, -e, --eval, -p, --print. .load and the other entries work for input that the REPL transform accepts (see "Not fixed here").
  • Settings, each run with bun -e and with bun repl -e and compared: development (jsxDEV), production (jsx, jsxs), a key after a spread (createElement from the package itself), two generated imports in one input, a written import beside JSX, tsconfig jsxImportSource, --jsx-import-source, a @jsxImportSource pragma.
  • Negative contracts: input without JSX imports nothing, the runtime module is evaluated once for four JSX inputs, and the classic runtime (tsconfig, flags, pragmas) imports nothing. Those 3 classic-runtime tests pass on every build.
  • Failure paths: a runtime that is not installed (the prompt continues, -e exits with 1, no statement of the input runs), and the registry request when no node_modules directory is found (a local server that answers 404).
  • A build of the base source fails 17 of the 20 new tests, and this branch passes 20 of 20. Both were run as release builds of the first version of this branch and as debug builds of the current one.

Why stacked on #45915, and what each order gives

A debug build of main ad4089bf49 with only the repl.rs line makes JSX evaluate at every entry, with these faults:

  • function A() { return <div /> }, then an input under /** @jsxImportSource other */, then A(): A now calls the runtime of other. The same happens after .load of a file with that pragma, and after process.chdir to a directory with another copy of react followed by a JSX input.
  • After var react = "mine" and <div {...props} key="k" />, react is the module namespace.
  • Three JSX inputs (element, fragment, key after a spread) add 5 names to globalThis: jsx_dev_runtime, jsxDEV_7x81h0kn, Fragment_8vg9x3sq, react, createElement_mvmpqhxp.

On #45915 with the same line, the component keeps its runtime in all three orders, user globals with those names keep their values, and 0 names are added. The tests a component keeps the JSX runtime of its own input and JSX input adds no name of its own to the global object hold these.

The two source hunks apply to main unchanged. If the line lands before #45915, those two tests must wait for #45915, and a session that uses two JSX runtimes sees the faults above.

Relation to open PRs

Costs in detail

  • Imports per input: 1 await import() for an element, a fragment, or a key after a spread (of the package itself, for createElement). 2 for an input that holds a key after a spread and another element, the runtime first. A written import in the same input is awaited after them.
  • No statement of an input runs when its runtime import fails, as in a file (the table above).
  • The runtime resolves from the working directory at the time of the input, as a typed import does. When neither that directory nor a parent has node_modules, the default auto-install asks the registry:
    • The registry answers 404: 1 request for two JSX inputs, then Cannot find module. Input without JSX makes 0 requests.
    • The registry accepts the connection and never answers: each JSX input blocks for 36 s, then prints note: Timeout downloading package manifest react.
    • At a terminal, Ctrl+C during that wait has no effect. Presses made during the wait are read after it, and two of them end the session. A typed import at the prompt has the same wait. repl: interrupt a running evaluation with Ctrl+C #33411 is the open PR for Ctrl+C during an evaluation.
  • After process.chdir out of a project, a JSX input takes that registry path (1 request, in a test). After a chdir into another project it loads that project's copy, so one session can hold two copies of the runtime.
  • After a JSX input fails because the runtime is not installed, installing it does not help until the REPL restarts: the resolver keeps the miss. Resolver: run a missed lookup once more at a fresh Generation, with DirInfo and not-found marks under Generation #40279 is the open PR for that.

Measurements

Release builds (LTO) in one build directory, made for the first version of this branch: P = 25ef6936bd (the parent of #45915 then), B = e5f204e9f8 (#45915 then), H = B with this diff. #45915 was rebased since. The diff of this PR is the same. Tools: gdb 16.3 (breakpoint hit counts, stepi with scheduler-locking, catch syscall), llvm-objdump 23, size, nm -S. perf, valgrind, strace and bloaty are not installed.

  • JSX-free REPL input: +0 allocations per input inside transform_for_repl (gdb hit count on the mimalloc entry points, B 38 / 46 / 51 against H 38 / 46 / 51 for 1+1, var n: number = 1, function f(){return 1}, 3 runs each).
  • Import gate, JSX-free parse: REPL input 4 -> 57 instructions. .tsx file with no JSX 163 -> 57 instructions, .js 168 -> 60, .ts 2 -> 2 (gdb stepi, B against H). transform_for_repl runs 2 more instructions (one load, one store).
  • JSX-loader file with no JSX: 2 -> 0 mimalloc heap allocations at the import gate per parse (.tsx and .js, 26 bytes). For .ts: 0 -> 0. This is a count, not a speed claim: about 105 instructions per parse is far below what a timing can show, and no test can pin it because the output is the same.
  • Release .text: +0 bytes for this PR (size, 106,790,986 in B and in H). By nm -S: _parse::<false> +28, _parse::<true> +24, transform_for_repl +0. Its base replMode: keep helper imports and lowering temporaries in the wrapper #45915 is +11,264 bytes over P.
  • End state against P: +4 instructions per parsed module, from replMode: keep helper imports and lowering temporaries in the wrapper #45915 and not from this PR (the single-part condition, now at parse_entry.rs:1063, runs 6 instructions where P runs 2, under bun build and bun run). This PR changes 0 instructions in _parse outside the JSX import gate. In _parse::<true> the compiler places two basic blocks in another order.
  • JSX-free inputs: 0 differing bytes in the REPL transcript, B against H (99 inputs in one piped session, 8,864 bytes of stdout, 0 of stderr). 0 of 102 replMode outputs differ with autoImportJSX off and on.
  • JSX runtime module evaluations: 0 before the first JSX input, 1 after 4 JSX inputs, 0 in a JSX-free -e run (the stand-in records each evaluation).
  • Global names added by 3 JSX inputs (element, fragment, key after a spread): 0 (main with the flag alone: 5).
  • At a TTY: +7 ioctl and +2 rt_sigaction per JSX input, 0 per JSX-free input. An await 1 input costs the same 7 and 2 on B.
  • The import gate for an input that has JSX: 6,220 instructions and 12 allocation calls (1,672 bytes) to build the import.
  • Registry requests in a directory with no node_modules (a local server): JSX-free session 0, JSX input 1 (GET /react). A typed import of the runtime makes the same request on 1.4.3.
  • Tests: 0 existing expectations changed (+371 -0).

Not fixed here

All of these are the same on 1.4.3. They show now because JSX evaluates.

  • An element left open at Enter with no open bracket (<ul> Enter): each line is evaluated alone and prints SyntaxError: Unexpected token '<'. The prompt continues only on open brackets, braces and parentheses. Use parentheses, .editor or .load.
  • JSX text with a quote (<p>it's</p>): the prompt waits for more input, and on a pipe the rest of the script is dropped. The same byte scan (is_incomplete_code) decides both. repl: recognize regex literals and comments in continuation detection #37327 rewrites that scan.
  • A syntax mistake (<div></span>): the REPL prints JavaScriptCore's Unexpected token '<', where bun -e prints Expected closing JSX tag to match opening tag "<div>". transform_for_repl drops the parser's log and its callers evaluate the raw text. repl: evaluate input the REPL transform does not take through each caller's result path (-e/-p exit code, .copy, _) #45267 and replMode: decide between an object literal and a block in the parser #43770 rewrite those lines.
  • An input that starts with {, has no trailing ; and is a block: { const el = <div />; console.log(el) } prints the same SyntaxError. A trailing ; makes it work. TypeScript and await in such a block fail the same way. An object literal ({ el: <div /> }) works and has a test.
  • export function App() {}, export default and export { } print SyntaxError: Unexpected keyword 'export', also through .load. Import the file at the prompt instead: import App from "./App.tsx".
  • Bun.Transpiler({ replMode: true }) still prints the bare jsxDEV_7x81h0kn(...) call unless autoImportJSX: true is passed. That is deliberate here: the option is opt-in at the API (Auto import jsx-runtime to fix: Can't find variable: jsxDEV #7499). Bun.Transpiler: default autoImportJSX to true for the automatic runtime #35557 is an open proposal to change the default, not a plan this PR relies on.
  • bun pm diff parses with the same default (src/runtime/cli/pm_diff_normalize.rs:318). Its output is compared, not run.
  • The hashed name is fixed and the same in every input. An input that declares jsxDEV_7x81h0kn itself beside JSX shares the name with the import at the prompt. A file refuses that source with a SyntaxError.
  • The development call passes this as its last argument: globalThis at the prompt, undefined in a module.
  • Windows was not run locally. The change has no platform branch, and the new tests use the piped harness that already runs on Windows.

Options that the prompt still does not take from the VM

transform_for_repl sets each parser option by hand. After this PR the prompt still differs from a file in five settings (each row run on this build with bun -e and bun repl -e):

Setting File Prompt, before and after this PR Owner
Decorator grammar TC39: a method decorator gets (function, context), and accessor fields parse Legacy TypeScript: (prototype, "name"), and class C { accessor x = 1 } is a SyntaxError No PR. Needs #45557 (the argument) and #45915 (temporaries that stay in the wrapper)
tsconfig emitDecoratorMetadata Emits design:type Ignored The same
tsconfig useDefineForClassFields: false A subclass field calls the setter Ignored The same
--no-macros error: Macros are disabled A macro runs #45937
--feature=X feature("X") is true False No PR

The decorator row is why these are not in this PR: the switch to TC39 decorators changes input that works at the prompt today.

Self-review: what was not taken

  • Forwarding the other five options in this PR. The table above names each and its owner.
  • Moving the parser commit to its own PR. It stays as its own commit: without it, an input without JSX pays two allocations for the gate that this PR opens. It carves out cleanly if a reviewer prefers that.

Designs considered

  • A parser default (Features::default().auto_import_jsx = true), or the import gate keyed on repl_mode. Bun.Transpiler exposes the option, and when Auto import jsx-runtime to fix: Can't find variable: jsxDEV #7499 asked for the import, the answer was that the caller opts in with autoImportJSX: true. bun pm diff parses with the default too. The flag at the one caller is the smaller change.
  • A required argument on ParserOptions::init. 8 call sites change for 1 that prints code that runs, and parser: require the tsconfig options in ParserOptions::init, fix scanImports on decorators and accessor fields #45557 changes the same signature.
  • The REPL options from Transpiler::parse. It would also close the five gaps above. But it sets more options from the VM that are right for a file and not for the prompt, for example constant inlining (a later input can redeclare a const) and the native using (the REPL needs the lowered form). repl_mode is not a per-parse option there, because the VM's transpiler also parses imported modules and macros. It is a possible end state, not this fix.

Suites run on the debug build

test/js/bun/repl/repl.test.ts (153 pass), test/js/bun/transpiler/repl-transform.test.ts (66 pass), test/bundler/transpiler/transpiler.test.js (238 pass), test/bundler/bundler_jsx.test.ts (64 pass). test/cli/run/run-autoinstall.test.ts: 297 pass. 3 of its tests reach the 5 s timeout in the full debug run and pass when run alone. This PR does not touch that code.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/repl/repl.test.ts

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:55 AM PT - Oct 21st, 2026

❌ @robobun, your commit 08c7787 has 1 failures in Build #130052 (All Failures):

  • step failed outside runner - exit 1 on ⚙️

🧪   To try this PR locally:

bunx bun-pr 46105

That installs a local version of the PR into your bun-46105 executable, so you can run:

bun-46105 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the robobun/12d3c09e/repl-persist-declared-names branch from e5f204e to c7d0fa6 Compare October 20, 2026 02:54
@robobun
robobun requested a review from alii as a code owner October 20, 2026 02:54
`Repl::transform_for_repl` builds its parser options by hand and left
`features.auto_import_jsx` at its default, false. Under the automatic
JSX runtime the parser then printed the call to the runtime function
without the import that declares it, so every JSX input threw
`ReferenceError: jsxDEV_7x81h0kn is not defined`.

Forward the option from the VM's transpiler, as `Transpiler::parse`
does for a file. The REPL transform already turns the generated import
into an awaited `import()`.
@robobun
robobun force-pushed the robobun/f54243c9/repl-jsx-runtime-import branch from 6959cc7 to c1b756a Compare October 21, 2026 09:49
Comment thread src/js_parser/parse/parse_entry.rs Resolved
Comment thread src/js_parser/parse/parse_entry.rs Resolved
The automatic JSX import block copied `import_source` and
`package_name` into the arena before it knew whether the file used any
JSX. Move each copy into the branch that generates its import. A parse
with no JSX element no longer allocates there.
@robobun
robobun force-pushed the robobun/f54243c9/repl-jsx-runtime-import branch from c1b756a to 08c7787 Compare October 21, 2026 09:54

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants